Coves frontend - a photon fork
1import type { RequestHandler } from './$types'
2import { DEFAULT_INSTANCE_URL } from '$lib/app/instance.svelte'
3import { validateProxyPath } from '../validate'
4
5/**
6 * =============================================================================
7 * API PROXY SECURITY MODEL
8 * =============================================================================
9 *
10 * PURPOSE:
11 * This proxy exists to keep authentication tokens secure by never exposing them
12 * to the browser. Authentication is managed via a backend-delegated session: the
13 * Coves Go backend sets a sealed (encrypted) session cookie during OAuth, and
14 * the SvelteKit frontend forwards that cookie to the backend's /api/me endpoint
15 * for validation. The proxy injects the Authorization header (using the sealed
16 * token from the cookie) on behalf of the client, so the client never needs to
17 * handle or store tokens.
18 *
19 * TRUST MODEL:
20 * - Client -> Proxy: Client is untrusted. All paths are validated for security
21 * issues (traversal, injection, etc.). The proxy only forwards to the
22 * pre-configured backend instance URL from the user's session.
23 * - Proxy -> Backend: Backend is trusted. The proxy forwards requests with
24 * auth headers to the Coves server at the user's registered instance URL.
25 *
26 * PATH VALIDATION:
27 * The path is validated to prevent:
28 * - Path traversal attacks (../ patterns)
29 * - Null byte injection (can truncate paths)
30 * - Protocol injection (javascript:, data:, etc.)
31 * - Encoded path separators that could bypass validation
32 *
33 * HEADER HANDLING:
34 * Stripped from request:
35 * - 'host': Prevents host header attacks; backend should see its own host
36 * - 'connection': Hop-by-hop header, not meant to be forwarded
37 *
38 * Added to request:
39 * - 'Authorization': Bearer token from encrypted session (if authenticated)
40 *
41 * Stripped from response:
42 * - 'content-encoding': Let SvelteKit handle compression; avoids double-encoding
43 *
44 * =============================================================================
45 */
46
47/**
48 * Handles proxying requests to the upstream Coves server.
49 * Injects the Authorization header from the session if available.
50 */
51async function handler({
52 params,
53 request,
54 locals,
55 fetch: fetchFn,
56}: {
57 params: { path: string }
58 request: Request
59 locals: App.Locals
60 fetch: typeof fetch
61}): Promise<Response> {
62 const path = params.path
63
64 // Validate path for security issues
65 const pathError = validateProxyPath(path)
66 if (pathError) {
67 return new Response(
68 JSON.stringify({ error: 'Bad Request', message: pathError }),
69 {
70 status: 400,
71 headers: { 'Content-Type': 'application/json' },
72 },
73 )
74 }
75
76 // Determine target instance (from session or default)
77 // Instance may already include protocol (e.g., "https://coves.social") or be just the hostname
78 const instance = locals.auth.authenticated
79 ? locals.auth.account.instance
80 : DEFAULT_INSTANCE_URL
81 let baseUrl: string
82 if (instance.startsWith('http://') || instance.startsWith('https://')) {
83 // Instance already has protocol, use as-is
84 baseUrl = instance
85 } else {
86 // Instance is just hostname, add https://
87 baseUrl = `https://${instance}`
88 }
89
90 // In production, only allow HTTPS URLs to prevent MITM attacks
91 if (import.meta.env.PROD && baseUrl.startsWith('http://')) {
92 return new Response(
93 JSON.stringify({
94 error: 'Bad Request',
95 message: 'HTTP URLs are not allowed in production',
96 }),
97 {
98 status: 400,
99 headers: { 'Content-Type': 'application/json' },
100 },
101 )
102 }
103 // Remove trailing slash from baseUrl if present to avoid double slashes
104 // Preserve query parameters from the original request
105 const requestUrl = new URL(request.url)
106 const queryString = requestUrl.search
107 const targetUrl = `${baseUrl.replace(/\/$/, '')}/${path}${queryString}`
108
109 // Build headers for upstream request
110 const headers = new Headers(request.headers)
111
112 // Strip hop-by-hop and security-sensitive headers
113 // 'host' - Prevents host header attacks; backend should receive its own host
114 // 'connection' - Hop-by-hop header, not meant to be forwarded through proxies
115 headers.delete('host')
116 headers.delete('connection')
117
118 // Inject Authorization header from the sealed session cookie.
119 // The sealed token is opaque to the browser (encrypted by the Go backend),
120 // so raw access/refresh tokens are never exposed to client-side code.
121 if (locals.auth.authenticated) {
122 headers.set('Authorization', `Bearer ${locals.auth.authToken}`)
123 }
124
125 try {
126 // Forward request
127 const fetchOptions: RequestInit = {
128 method: request.method,
129 headers,
130 }
131
132 // Only include body for methods that support it.
133 // We consume the body as a Blob rather than streaming request.body
134 // (ReadableStream) because Node.js undici has issues with ReadableStream
135 // bodies in fetch(), causing "expected non-null body source" errors.
136 // Using Blob handles both text and binary content types correctly.
137 if (request.method !== 'GET' && request.method !== 'HEAD') {
138 fetchOptions.body = await request.blob()
139 }
140
141 const response = await fetchFn(targetUrl, fetchOptions)
142
143 // Return response, stripping headers that SvelteKit should handle
144 const responseHeaders = new Headers(response.headers)
145 // 'content-encoding' - Let SvelteKit handle compression to avoid double-encoding
146 responseHeaders.delete('content-encoding')
147
148 return new Response(response.body, {
149 status: response.status,
150 headers: responseHeaders,
151 })
152 } catch (error) {
153 // Generate a unique request ID for error correlation
154 const requestId = crypto.randomUUID().slice(0, 8) // Short ID for easier reference
155
156 // Connection error to upstream - include request context for debugging
157 console.error(
158 `Proxy error [${request.method} /${path}] [requestId: ${requestId}]:`,
159 error,
160 )
161 return new Response(
162 JSON.stringify({
163 error: 'Bad Gateway',
164 message: 'Failed to connect to upstream server',
165 requestId,
166 }),
167 {
168 status: 502,
169 headers: { 'Content-Type': 'application/json' },
170 },
171 )
172 }
173}
174
175// Handle all HTTP methods by wrapping the handler
176export const GET: RequestHandler = (event) => handler(event)
177export const POST: RequestHandler = (event) => handler(event)
178export const PUT: RequestHandler = (event) => handler(event)
179export const DELETE: RequestHandler = (event) => handler(event)
180export const PATCH: RequestHandler = (event) => handler(event)